#!/usr/bin/env python3

# Copyright (c) 2020-2021 Fpemud <fpemud@sina.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.


import os
import xml.etree.ElementTree
from typing import Dict, Iterable, List, Optional
from collections.abc import MutableMapping
from ._util import Util


class Manifest:

    """Represents a bundle of manifest files."""

    DEFAULT_MANIFEST_FILENAME = "default.xml"

    class ParseError(Exception):
        pass

    def __init__(self, rootdir: str, path: str, parent=None) -> None:
        self._rootdir = rootdir
        self._path = path

        # parse
        self._tree = None
        with open(self.fullpath, "r", encoding="utf-8") as inp:
            tree_builder = xml.etree.ElementTree.TreeBuilder(insert_comments=True)  # type: ignore
            tree_parser = xml.etree.ElementTree.XMLParser(target=tree_builder)
            self._tree = xml.etree.ElementTree.parse(inp, parser=tree_parser)

        # parse again for top-level comments
        self._top_level_comments: List[str] = []
        with open(self.fullpath, "r", encoding="utf-8") as inp:
            has_seen_manifest = False
            for event, element in xml.etree.ElementTree.iterparse(inp, events=("start", "comment")):
                if event == "start" and element.tag == "manifest":
                    has_seen_manifest = True
                if event == "comment" and not has_seen_manifest:
                    self._top_level_comments.append(element.text)

        self._includes: List[Manifest] = []
        self._defaults: ManifestDefaults = None
        self._remotes: Dict[str, ManifestRemote] = {}
        self._projects: Dict[str, ManifestProject] = {}
        self._repo_hooks: Optional[ManifestRepoHooks] = None
        self._superproject: Optional[ManifestSuperProject] = None

        # the manifest that was the source of the '<include>' that pulled in this manifest.
        self._parent = parent

        # parse a manifest from an xml tree
        root = self._tree.getroot()
        if root.tag != "manifest":
            raise self.ParseError("root node is not 'manifest'")
        for child in root:
            if child.tag == xml.etree.ElementTree.Comment:  # pylint: disable=comparison-with-callable
                self._handle_comment(child)
            elif child.tag == "include":
                self._handle_include(child)
            elif child.tag == "default":
                self._handle_default(child)
            elif child.tag == "remote":
                self._handle_remote(child)
            elif child.tag == "project":
                self._handle_project(child)
            elif child.tag == "repo-hooks":
                self._handle_repohooks(child)
            elif child.tag == "superproject":
                self._handle_superproject(child)
            elif child.tag == "contactinfo":
                # FIXME
                pass
            else:
                raise self.ParseError("no handler for {}".format(child.tag))
        if self._defaults is None:
            self._defaults = ManifestDefaults(self, None)

        # add parents for projects
        if self._parent is None:
            project_by_path = {x["path"]: x for x in self.get_projects()}
            for project in self.get_projects():
                parent_dir = project["path"]
                while parent_dir:
                    parent_dir, _ = os.path.split(parent_dir)
                    if parent_dir in project_by_path:
                        project._parent_project = project_by_path[parent_dir]
                        break

        # check remotes
        if self._parent is None:
            nameList = [x["name"] for x in self.get_remotes()]
            nameList = Util.listGetDuplicateItems(nameList)
            if len(nameList) > 0:
                raise self.ParseError(f"there are multiple remotes which have the name '{nameList[0]}' in {self.fullpath} and included manifests")

        # check projects
        if self._parent is None:
            pathList = [x["path"] for x in self.get_projects()]
            pathList = Util.listGetDuplicateItems(pathList)
            if len(pathList) > 0:
                raise self.ParseError(f"there are multiple projects which have the path '{pathList[0]}' in {self.fullpath} and included manifests")

    @property
    def fullpath(self) -> str:
        return os.path.join(self._rootdir, self._path)

    @property
    def path(self) -> str:
        return self._path

    @property
    def top_level_comments(self) -> Iterable[str]:
        return self._top_level_comments

    def get_includes(self) -> Iterable["Manifest"]:
        """
        Get manifests included by current manifest (directly or indirectly).
        """

        for manifest in self._includes:
            yield manifest
            for manifest2 in manifest.get_includes():
                yield manifest2

    def get_include(self, path: str) -> "Manifest":
        """
        Get a particular manifest included by current manifest, by relative path.
        """

        for manifest in self.get_includes():
            if manifest.path == path:
                return manifest
        raise KeyError(f"Unable to find a included manifest with relative path '{path}' for {self.fullpath}")

    def get_defaults(self) -> "ManifestDefaults":
        return self._defaults

    def get_remotes(self) -> Iterable["ManifestRemote"]:
        """
        Get the set of remotes known to this manifest.
        Turns out that repo is...clever. You can refer to a remote in any manifest file so long as it
        has been loaded via 'include' into the context that is shared by all manifests and repo is
        perfectly happy. This means I can load up manifest A, which includes B, which defines a remote
        X. Then manifest A can include another manifest C which can refer to X even though it doesn't
        define it.
        """

        def _yieldRemotes(manifest):
            for remote in manifest._remotes.values():
                yield remote
            for manifest2 in manifest._includes:
                for x in _yieldRemotes(manifest2):
                    yield x
        for x in _yieldRemotes(self._rootManifest()):
            yield x

    def has_remote(self, name: str) -> bool:
        """
        Test whether a particular remote exist by name.
        """

        for remote in self.get_remotes():
            if remote["name"] == name:
                return True
        return False

    def get_remote(self, name: str) -> "ManifestRemote":
        """
        Get a particular remote by name.
        """

        for remote in self.get_remotes():
            if remote["name"] == name:
                return remote
        raise KeyError(f"Unable to find a remote named '{name}' in {self.fullpath}")

    def get_projects(self) -> Iterable["ManifestProject"]:
        """
        Get the set of projects in this manifest.
        This includes any projects defined in this manifest or in any of its includes.
        """

        for project in self._projects.values():
            yield project
        for manifest in self._includes:
            for project in manifest.get_projects():
                yield project

    def has_project(self, path: str) -> bool:
        """
        Test whether a particular project exist by path.
        """

        for project in self.get_projects():
            if project["path"] == path:
                return True
        return False

    def get_project(self, path: str) -> "ManifestProject":
        """
        Get a particular project by path.
        """

        for project in self.get_projects():
            if project["path"] == path:
                return project
        raise KeyError(f"Unable to find a project having path '{path}' in {self.fullpath}")

    def add_remote(self, name: str, fetch: str, review: Optional[str]=None, revision: Optional[str]=None) -> None:
        assert not self.has_remote(name)

        node = xml.etree.ElementTree.Element('remote')
        node.attrib["name"] = name
        node.attrib["fetch"] = fetch
        if review is not None:
            node.attrib["review"] = review
        if revision is not None:
            node.attrib["revision"] = revision
        self._tree.getroot().append(node)

        remote = ManifestRemote(self, node)
        self._remotes[remote["name"]] = remote

    def remove_remote(self, name: str) -> None:
        self.get_remote(name).remove_self()

    def add_project(self, path: str, name: str) -> None:
        assert False

    def remove_project(self, path: str) -> None:
        self.get_project(path).remove_self()

    def save(self) -> None:
        """
        Save the manifest, and its includes, back to their original files.
        This should incorporate any changes that have been manually made to the files.
        """

        for include in self._includes:
            include.save()

        with open(self.fullpath, "wb") as out:
            out.write("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".encode("UTF-8"))
            for comment in self._top_level_comments:
                out.write(("<!--" + comment + "-->\n").encode("UTF-8"))
            self._tree.write(out)

    def _handle_comment(self, node: xml.etree.ElementTree.Element) -> None:
        pass

    def _handle_include(self, node: xml.etree.ElementTree.Element) -> None:
        # Most includes are strictly in the same directory. The special
        # .repo/manifest.xml file may include files from .repo/manifests/

        if self._parent is not None:
            newpath = os.path.join(os.path.dirname(self.path), node.attrib["name"])
        else:
            newpath = os.path.join("manifests", node.attrib["name"])
        rootdir = os.path.dirname(self._rootManifest().fullpath)
        self._includes.append(Manifest(rootdir, newpath, parent=self))

    def _handle_default(self, node: xml.etree.ElementTree.Element) -> None:
        self._defaults = ManifestDefaults(self, node)

    def _handle_remote(self, node: xml.etree.ElementTree.Element) -> None:
        remote = ManifestRemote(self, node)
        if remote["name"] in self._remotes:
            raise self.ParseError("there are multiple remotes which have the name '%s' in %s" % (remote["name"], self.fullpath))
        self._remotes[remote["name"]] = remote

    def _handle_project(self, node: xml.etree.ElementTree.Element) -> None:
        project = ManifestProject(self, node)
        if project["path"] in self._projects:
            raise self.ParseError("there are multiple projects which have the path '%s' in %s" % (project["path"], self.fullpath))
        self._projects[project["path"]] = project

    def _handle_repohooks(self, node: xml.etree.ElementTree.Element) -> None:
        self._repo_hooks = ManifestRepoHooks(self, node)

    def _handle_superproject(self, node: xml.etree.ElementTree.Element) -> None:
        self._superproject = ManifestSuperProject(self, node)

    def _rootManifest(self):
        root_manifest = self
        while root_manifest._parent is not None:
            root_manifest = root_manifest._parent
        return root_manifest


class ManifestDefaults(MutableMapping[str, str]):

    def __init__(self, manifest, node):
        self._validKeys = ["remote", "revision", "sync-c", "sync-j"]
        self._manifest = manifest
        self._node = node

    @property
    def manifest(self):
        return self._manifest

    def __getitem__(self, key):
        assert key in self._validKeys

        # we have this value
        if self._node is not None and key in self._node.attrib:
            return self._node.attrib[key]

        # fallback to parent manifest
        if self._manifest._parent is not None:
            return self._manifest._parent.get_defaults()[key]

        # default value
        if key == "revision":
            return "master"

        raise KeyError("FIXME")

    def __setitem__(self, key, value):
        assert key in self._validKeys

        assert self._node is not None           # FIXME
        self._node.attrib[key] = value

    def __delitem__(self, key):
        assert key in self._validKeys

        assert self._node is not None           # FIXME
        del self._node.attrib[key]

    def __iter__(self):
        return iter(self._node.attrib)

    def __len__(self):
        return len(self._node.attrib)


class ManifestRemote(MutableMapping[str, str]):

    def __init__(self, manifest, node):
        self._validKeys = [
            "name",
            "fetch",            # URL for fetch?
            "review",           # URL for review?
            "revision",
        ]
        self._optionalKeys = [
            "review",           # "repo upload" won't take effect if this key does not exist
            "revision",
        ]                       # on contrary to the spec, "fetch" key is always specified in reality, so it's not in optional keys
        self._masterKey = "name"

        self._manifest = manifest
        self._node = node

        for k in set(self._validKeys) - set(self._optionalKeys):
            if k not in self._node.attrib:
                raise self._manifest.ParseError("remote named '%s' has no attribute %s in %s" % (self._node.attrib["name"], k, self._manifest.fullpath))

    @property
    def manifest(self):
        return self._manifest

    def __getitem__(self, key):
        assert key in self._validKeys
        return self._node.attrib[key]

    def __setitem__(self, key, value):
        assert key in self._validKeys and key != self._masterKey
        self._node.attrib[key] = value

    def __delitem__(self, key):
        assert key in self._optionalKeys
        del self._node.attrib[key]

    def __iter__(self):
        return iter(self._node.attrib)

    def __len__(self):
        return len(self._node.attrib)

    def remove_self(self):
        remote_name = self._node.attrib["name"]

        # remove xml element (self._manifest._tree.getroot() must be the parent of self._node)
        self._manifest._tree.getroot().remove(self._node)

        # remove from manifest object
        del self._manifest._remotes[remote_name]

        # dispose this object
        del self._node
        del self._manifest

    def __repr__(self):
        return str(self)

    def __str__(self):
        return "ManifestRemote %s fetch %s review %s" % (self["name"], self["fetch"], self.get("review"))


class ManifestProject:

    def __init__(self, manifest, node):
        self._validKeys = [
            "path",
            "name",
            "remote",
            "review",
            "revision",
            "sheriff",
        ]
        self._optionalKeys = [
            "name",
            "remote",
            "review",
            "revision",
            "sheriff",
        ]
        self._masterKey = "path"

        self._manifest = manifest
        self._node = node
        self._parent_project = None

        for k in set(self._validKeys) - set(self._optionalKeys):
            if k not in self._node.attrib:
                raise self._manifest.ParseError("project with path '%s' has no attribute %s in %s" % (self._node.attrib["path"], k, self._manifest.fullpath))

    @property
    def manifest(self):
        return self._manifest

    def __getitem__(self, key):
        assert key in self._validKeys

        if key == "remote":
            ret = self._getRemote()
            if ret is None:
                raise KeyError("FIXME")
            return ret
        elif key == "revision":
            return self._getRevision()
        else:
            return self._node.attrib[key]

    def __setitem__(self, key, value):
        assert key in self._validKeys and key != self._masterKey
        self._node.attrib[key] = value

    def __delitem__(self, key):
        assert key in self._optionalKeys
        del self._node.attrib[key]

    def __iter__(self):
        ret = set(self._node.attrib.keys())
        ret.add("remote")
        ret.add("revision")
        return iter(ret)

    def __len__(self):
        return len(self._node.attrib)

    def remove_self(self):
        project_path = self._node.attrib["path"]

        # remove xml element (self._manifest._tree must be the parent of self._node)
        self._manifest._tree.getroot().remove(self._node)

        # remove from manifest object
        del self._manifest._projects[project_path]

        # dispose this object
        del self._node
        del self._manifest

    def __repr__(self) -> str:
        return str(self)

    def __str__(self) -> str:
        return "ManifestProject %s at %s from %s on %s" % (self["name"], self["path"], self["remote"]["name"], self.get("revision"))

    def _getRemote(self):
        remote_name = self._node.attrib.get("remote", self._manifest.get_defaults().get("remote"))
        if remote_name is not None:
            return self._manifest.get_remote(remote_name)
        else:
            return None

    def _getRevision(self):
        return self._node.attrib.get("revision", self._manifest.defaults["revision"])


class ManifestRepoHooks:

    def __init__(self, manifest, node: xml.etree.ElementTree.Element):
        self._manifest = manifest
        self._node = node

    @property
    def in_project(self) -> Optional[str]:
        return self._node.attrib.get("in-project")

    @property
    def enabled_list(self) -> Optional[str]:
        return self._node.attrib.get("enabled-list")


class ManifestSuperProject:

    def __init__(self, manifest, node: xml.etree.ElementTree.Element):
        self._manifest = manifest
        self._node = node

    @property
    def name(self) -> str:
        return self._node.attrib["name"]

    @property
    def remote(self) -> str:
        return self._node.attrib["remote"]
